從 Day 19 開始,我陸續學了 TypeScript 的型別、介面、Enum、泛型、DTO、Prisma ORM、錯誤處理……
今天要把這些全部整合起來,打造一個「真正能上線」的小作品:
👉 Todo API(TypeScript 版)
這次不只是寫功能,而是讓整體架構「有設計感」。
最後成品的目錄長這樣 👇
src/
├── controllers/
│ └── todo.controller.ts
├── services/
│ └── todo.service.ts
├── dto/
│ ├── create-todo.dto.ts
│ └── update-todo.dto.ts
├── middleware/
│ ├── error-handler.ts
│ └── validate-dto.ts
├── prisma/
│ └── client.ts
├── routes/
│ └── todo.routes.ts
├── utils/
│ └── http-exception.ts
└── index.ts
乾淨明確,每一層都有自己的職責。
prisma/schema.prisma
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Todo {
id Int @id @default(autoincrement())
task String
done Boolean @default(false)
note String?
createdAt DateTime @default(now())
}
然後執行初始化:
npx prisma migrate dev --name init
npx prisma generate
src/dto/create-todo.dto.ts
import { IsString, IsOptional, Length } from "class-validator";
export class CreateTodoDto {
@IsString()
@Length(1, 50, { message: "task 長度必須在 1~50 之間" })
task: string;
@IsOptional()
@IsString()
note?: string;
}
src/dto/update-todo.dto.ts
import { IsOptional, IsString, IsBoolean } from "class-validator";
export class UpdateTodoDto {
@IsOptional()
@IsString()
task?: string;
@IsOptional()
@IsBoolean()
done?: boolean;
@IsOptional()
@IsString()
note?: string;
}
src/utils/http-exception.ts
export class HttpException extends Error {
status: number;
message: string;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
src/middleware/error-handler.ts
import { Request, Response, NextFunction } from "express";
import { HttpException } from "../utils/http-exception";
export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction) {
console.error("❌ 錯誤:", err);
if (err instanceof HttpException) {
return res.status(err.status).json({ error: err.message });
}
return res.status(500).json({ error: "伺服器內部錯誤" });
}
src/middleware/validate-dto.ts
import { plainToInstance } from "class-transformer";
import { validate } from "class-validator";
import { Request, Response, NextFunction } from "express";
export function validateDto(dtoClass: any) {
return async (req: Request, res: Response, next: NextFunction) => {
const instance = plainToInstance(dtoClass, req.body);
const errors = await validate(instance);
if (errors.length > 0) {
const messages = errors.map(err => Object.values(err.constraints || {})).flat();
return res.status(400).json({ errors: messages });
}
next();
};
}
src/services/todo.service.ts
import prisma from "../prisma/client";
import { HttpException } from "../utils/http-exception";
export class TodoService {
async findAll() {
return prisma.todo.findMany();
}
async create(task: string, note?: string) {
return prisma.todo.create({ data: { task, note } });
}
async update(id: number, data: any) {
const todo = await prisma.todo.findUnique({ where: { id } });
if (!todo) throw new HttpException(404, "找不到這筆 Todo");
return prisma.todo.update({ where: { id }, data });
}
async delete(id: number) {
const todo = await prisma.todo.findUnique({ where: { id } });
if (!todo) throw new HttpException(404, "找不到這筆 Todo");
await prisma.todo.delete({ where: { id } });
return { message: "Todo 已刪除" };
}
}
src/controllers/todo.controller.ts
import { Request, Response, NextFunction } from "express";
import { TodoService } from "../services/todo.service";
const todoService = new TodoService();
export class TodoController {
async getAll(req: Request, res: Response, next: NextFunction) {
try {
const todos = await todoService.findAll();
res.json(todos);
} catch (err) {
next(err);
}
}
async create(req: Request, res: Response, next: NextFunction) {
try {
const { task, note } = req.body;
const todo = await todoService.create(task, note);
res.status(201).json(todo);
} catch (err) {
next(err);
}
}
async update(req: Request, res: Response, next: NextFunction) {
try {
const id = Number(req.params.id);
const todo = await todoService.update(id, req.body);
res.json(todo);
} catch (err) {
next(err);
}
}
async delete(req: Request, res: Response, next: NextFunction) {
try {
const id = Number(req.params.id);
const result = await todoService.delete(id);
res.json(result);
} catch (err) {
next(err);
}
}
}
src/routes/todo.routes.ts
import { Router } from "express";
import { validateDto } from "../middleware/validate-dto";
import { CreateTodoDto } from "../dto/create-todo.dto";
import { UpdateTodoDto } from "../dto/update-todo.dto";
import { TodoController } from "../controllers/todo.controller";
const router = Router();
const controller = new TodoController();
router.get("/", controller.getAll.bind(controller));
router.post("/", validateDto(CreateTodoDto), controller.create.bind(controller));
router.put("/:id", validateDto(UpdateTodoDto), controller.update.bind(controller));
router.delete("/:id", controller.delete.bind(controller));
export default router;
src/index.ts
import "reflect-metadata";
import express from "express";
import todoRoutes from "./routes/todo.routes";
import { errorHandler } from "./middleware/error-handler";
const app = express();
app.use(express.json());
app.use("/todos", todoRoutes);
app.use(errorHandler); // 全域錯誤處理放最後
app.listen(3000, () => console.log("🚀 TS Todo API running on http://localhost:3000"));
curl -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"task":"Day 27 收尾測試","note":"TypeScript 完整版"}'
curl http://localhost:3000/todos
curl -X PUT http://localhost:3000/todos/1 \
-H "Content-Type: application/json" \
-d '{"done":true}'
curl -X DELETE http://localhost:3000/todos/1
# TS Todo API
一個以 TypeScript + Express + Prisma 建構的 Todo API 小作品。
## 功能
- 建立、查詢、更新、刪除 Todo
- DTO 驗證輸入資料
- 全域錯誤處理統一 JSON 格式
- Prisma ORM 操作資料庫(SQLite)
## 使用方式
```bash
npm install
npx prisma migrate dev --name init
npm run dev
伺服器啟動後:
http://localhost:3000/todos
Day 27 是整個 TypeScript 後端階段的完結篇。
回頭看這幾天,從學型別、泛型、DTO、再到 ORM、錯誤處理、架構分層,
我終於把這些「分散的概念」組成一個能實際運作的專案。
✨ 今天的成果代表:
這幾天就像在蓋一棟房子——
前面在學地基和水電,今天終於可以住進去了 🏠